昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」
想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!
今天結束後,你將學會:
vi.fn()
和 vi.spyOn()
用法第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)
測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。
Stub(存根) - 提供固定回應
Mock(模擬) - 驗證互動行為
Spy(間諜) - 監控真實行為
先建立一個簡單的 EmailService 和 NotificationService。
建立 src/email_service.ts
:
export class EmailService {
send(to: string, subject: string, body: string): boolean {
// 實際實作會真的寄信
console.log(`Sending email to ${to}`)
return true
}
}
建立 src/notification_service.ts
:
import { EmailService } from './email_service'
export class NotificationService {
constructor(private emailService: EmailService) {}
notify(userEmail: string, message: string): boolean {
return this.emailService.send(
userEmail,
'Notification',
message
)
}
}
建立 tests/day07/notification_service.test.ts
:
import { describe, it, expect, vi } from 'vitest'
import { NotificationService } from '../../src/notification_service'
describe('NotificationService with Mock', () => {
it('sends email when notifying user', () => {
// 建立 Mock
const mockEmailService = {
send: vi.fn().mockReturnValue(true)
}
const notificationService = new NotificationService(mockEmailService)
// 執行測試
const result = notificationService.notify('user@example.com', 'Hello!')
// 驗證結果
expect(result).toBe(true)
// 驗證 Mock 被正確呼叫
expect(mockEmailService.send).toHaveBeenCalledWith(
'user@example.com',
'Notification',
'Hello!'
)
expect(mockEmailService.send).toHaveBeenCalledTimes(1)
})
})
建立 src/random_generator.ts
:
export class RandomGenerator {
generate(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
}
建立 src/game_service.ts
:
import { RandomGenerator } from './random_generator'
export class GameService {
constructor(private randomGenerator: RandomGenerator) {}
rollDice(): number {
return this.randomGenerator.generate(1, 6)
}
isWinning(diceValue: number): boolean {
return diceValue >= 4
}
}
建立 tests/day07/game_service.test.ts
:
import { describe, it, expect, vi } from 'vitest'
import { GameService } from '../../src/game_service'
describe('GameService with Stub', () => {
it('wins when dice value is 4 or higher', () => {
// 建立 Stub - 固定回傳 5
const stubRandomGenerator = {
generate: vi.fn().mockReturnValue(5)
}
const gameService = new GameService(stubRandomGenerator)
const diceValue = gameService.rollDice()
const isWin = gameService.isWinning(diceValue)
expect(diceValue).toBe(5)
expect(isWin).toBe(true)
})
it('loses when dice value is less than 4', () => {
// 建立 Stub - 固定回傳 2
const stubRandomGenerator = {
generate: vi.fn().mockReturnValue(2)
}
const gameService = new GameService(stubRandomGenerator)
const diceValue = gameService.rollDice()
const isWin = gameService.isWinning(diceValue)
expect(diceValue).toBe(2)
expect(isWin).toBe(false)
})
})
建立 src/calculator_with_logger.ts
:
export class Logger {
log(message: string): void {
console.log(`[LOG] ${message}`)
}
}
export class Calculator {
constructor(private logger: Logger) {}
add(a: number, b: number): number {
const result = a + b
this.logger.log(`Adding ${a} + ${b} = ${result}`)
return result
}
subtract(a: number, b: number): number {
const result = a - b
this.logger.log(`Subtracting ${a} - ${b} = ${result}`)
return result
}
}
建立 tests/day07/calculator_with_spy.test.ts
:
import { describe, it, expect, vi } from 'vitest'
import { Calculator, Logger } from '../../src/calculator_with_logger'
describe('Calculator with Spy', () => {
it('logs calculation when adding', () => {
const logger = new Logger()
// 使用 Spy 監控 log 方法
const logSpy = vi.spyOn(logger, 'log')
const calculator = new Calculator(logger)
const result = calculator.add(2, 3)
// 驗證計算結果
expect(result).toBe(5)
// 驗證 log 被呼叫
expect(logSpy).toHaveBeenCalledWith('Adding 2 + 3 = 5')
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('logs calculation when subtracting', () => {
const logger = new Logger()
const logSpy = vi.spyOn(logger, 'log')
const calculator = new Calculator(logger)
const result = calculator.subtract(5, 3)
expect(result).toBe(2)
expect(logSpy).toHaveBeenCalledWith('Subtracting 5 - 3 = 2')
})
})
// ✅ 好的做法:清楚的測試意圖
it('sends notification email', () => {
const mockEmail = { send: vi.fn().mockReturnValue(true) }
// ... 簡單明瞭的測試
})
// ❌ 避免:過度複雜的設置
it('does everything', () => {
// 10 行的 mock 設置...
})
// ✅ 好的做法:專注單一行為
it('calls email service with correct parameters', () => {
// 只測試參數傳遞
})
it('returns true when email is sent successfully', () => {
// 只測試回傳值
})
// ✅ 好的做法:驗證重要的互動
expect(mockService.send).toHaveBeenCalledWith(expectedParams)
// ❌ 避免:過度驗證
expect(mock.method1).toHaveBeenCalledTimes(1)
expect(mock.method2).toHaveBeenCalledTimes(2)
expect(mock.method3).toHaveBeenCalledTimes(3)
// ... 太多不必要的驗證
今天我們學會了:
✅ 測試替身的三種類型
✅ Vitest 測試工具
vi.fn()
:建立 Mock 函數vi.spyOn()
:監控現有方法mockReturnValue()
:設定回傳值✅ 實務應用
試著為以下 PaymentService
寫測試:
class PaymentService {
constructor(
private gateway: PaymentGateway,
private logger: Logger
) {}
processPayment(amount: number): boolean {
this.logger.log(`Processing payment: $${amount}`)
if (amount <= 0) {
this.logger.log('Invalid amount')
return false
}
const result = this.gateway.charge(amount)
this.logger.log(`Payment result: ${result}`)
return result
}
}
提示:
PaymentGateway
的 charge
方法Logger
的 log
方法明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀